package com.googlecode.lightest.core

import org.apache.commons.cli.GnuParser
import org.codehaus.groovy.runtime.StackTraceUtils
import org.testng.ITestNGListener
import org.testng.TestNG
import org.testng.TestNGCommandLineArgs
import org.testng.xml.XmlClass
import org.testng.xml.XmlPackage
import org.testng.xml.XmlSuite

/**
 * The Lightest test runner.
 */
class TestRunner implements ITestRunner {
    /** the object used to find test instances, given a prepared TestNG */
    TestInstanceFinder finder
    
    /**
     * the ITestNGListener's to wire directly into the TestNG runner. Any
     * existing values will be overwritten if configure() is called afterwards.
     */
    List<ITestNGListener> testNGListeners
    
    String outputDir

    private InputStream _in
    private PrintStream out
    private AntBuilder ant
    private GroovyClassLoader classLoader
    private ILightestTestListener lightestListener
    private List<ITaskDispatcher> dispatchers
    private ITaskDispatchStrategy dispatchStrategy
    private boolean interactiveThreadShouldStop

    /**
     * if this runner was configured textually, the text will be stored to this
     * variable
     */
    private String configText
    
    TestRunner() {
        this(System.in, System.out)
    }
    
    TestRunner(InputStream _in, PrintStream out) {
        this(_in, out, new AntBuilder())
    }
    
    TestRunner(InputStream _in, PrintStream out, AntBuilder ant) {
        this._in = _in
        this.out = out
        this.ant = ant

        testNGListeners = []
        finder = new TestInstanceFinder()
        classLoader = new GroovyClassLoader(this.class.classLoader)
        dispatchStrategy = new InteractiveTaskDispatchStrategy(_in, out)
    }

    /**
     * Configures the runner by wiring together dependencies as specified by
     * the configuration file.
     *
     * @param configFile
     */
    void configure(File configFile) {
        configure(configFile.text)
    }

    /**
     * Configures the runner by wiring together dependencies as specified by
     * the configuration text.
     *
     * @param configText
     */
    void configure(String configText) {
        def config = new Configuration()
        
        config.init(configText)
        configure(config)
        this.configText = configText
    }

    void configure(IConfiguration config = new Configuration()) {
        def classPaths = config.getClassPaths()
        def prefs = config.getPreferences()
        def envs = config.getEnvironments()
        def strategy = config.getDispatcherAssignmentStrategy()
        def reporters = config.getReporters()
        
        lightestListener = new LightestTestListener()
        testNGListeners = config.getListeners()
        dispatchStrategy = config.getTaskDispatchStrategy() ?: dispatchStrategy
        dispatchers = []

        reporters.each { reporter ->
            lightestListener.registerReporter(reporter)
        }
        
        outputDir = config.getOutputDir()
        classLoader = config.getClassLoader()

        classPaths.each { path ->
            def url = new File(path).getCanonicalFile().toURL()
            classLoader.addURL(url)
        }
        
        envs.each { env ->
            def contextClass = config.getContextClass()
            def contextFactory = new LightestContextFactory(contextClass,
                classLoader, outputDir)
            def context = new ThreadedLightestContext(contextFactory)
            def dispatcher = new TaskDispatcher(context)
            
            dispatcher.setPreferences(prefs)
            dispatcher.setEnvironment(env)
            dispatcher.setStrategy(dispatchStrategy)
            dispatcher.setListener(lightestListener)

            dispatchers << dispatcher
        }

        strategy.setDispatchers(dispatchers)
        lightestListener.setDispatcherAssignmentStrategy(strategy)
    }
    
    /**
     * Returns the current GroovyClassLoader, which may be used by TestNG to
     * load test and related classes. The underlying object may change if
     * configure() is invoked.
     */
    GroovyClassLoader getClassLoader() {
        return classLoader
    }

    /**
     * Uses a given class loader to load a Groovy class from a file.
     *
     * @param testFile
     * @param classLoader
     */
    private Class getClassFromFile(File testFile, GroovyClassLoader gcl) {
        assert testFile.exists()
        return gcl.parseClass(testFile.text)
    }
    
    private void setSuites(List<String> paths, TestNG testng) {
        def suitePaths = []
        def testClasses = []

        paths.each { path ->
            if (path.endsWith('.xml')) {
                suitePaths << path
            }
            else if (path.endsWith('.groovy')) {
                testClasses << getClassFromFile(new File(path), classLoader)
            }
            else {
                out.println "Skipping unrecognized file: ${path}"
            }
        }

        if (testClasses) {
            suitePaths << LightestUtils.createSuiteFile(testClasses).getPath()
        }

        testng.setTestSuites(suitePaths)
        addGroovyClassesForPackages(System.getProperty(
            TestNGCommandLineArgs.TEST_CLASSPATH), testng)
    }
    
    /**
     * Given a test classpath and a TestNG object which already has its suites
     * set, updates the XmlPackage information for each suite and test by
     * adding XmlClass entires for qualifying Groovy classes that exist in the
     * package. This is necessary because TestNG currently does not support
     * finding Groovy classes by package.
     */
    private void addGroovyClassesForPackages(String testClasspath,
        TestNG testng)
    {
        def classFinder = new GroovyClassFinder(classLoader, testClasspath)
        
        for (xmlSuite in testng.m_suites) {
            for (xmlPackage in xmlSuite.getXmlPackages()) {
                addGroovyClassesForPackage(classFinder, xmlPackage)
            }
            
            for (xmlTest in xmlSuite.getTests()) {
                for (xmlPackage in xmlTest.getXmlPackages()) {
                    addGroovyClassesForPackage(classFinder, xmlPackage)
                }
            }
        }
    }
    
    private void addGroovyClassesForPackage(GroovyClassFinder classFinder,
        XmlPackage xmlPackage)
    {
        def groovyClasses = classFinder.findByPackage(xmlPackage)
        def xmlClasses = []
        
        groovyClasses.each { className ->
            try {
                def testClass = Class.forName(className, true, classLoader)
                xmlClasses << new XmlClass(testClass, true)
            }
            catch (e) {
                out.println("Skipping unloadable class (syntax?): ${className}")
            }
        }
        
        xmlPackage.getXmlClasses().addAll(xmlClasses)
    }
    
    /**
     * Executes the test suites represented by XML files, along with tests
     * represented as Groovy files, whose paths are provided as a parameter to
     * this method. The contextual class loader of the TestNG run is a
     * GroovyClassLoader that is aware of any task classpaths specified in the
     * Configuration.
     *
     * @param paths             paths to files that are either Lightest suite
     *                          XML files, or Groovy scripts that are
     *                          LightestTestCase's.
     * @param interactiveStart  whether to start the runner in interactive
     *                          mode, if possible
     */
    void run(List<String> paths, boolean interactiveStart) {
        // the output directory is set on all appropriate collaborating classes
        // in this method, so they don't get out of sync
        assert outputDir != null
        
        // all class loading performed by TestNG should be done via this
        // loader. Everything having to do with the new TestNG object is
        // confined to execute after the context class loader has been set
        // here, for that reason.
        ant.delete (dir: outputDir, failonerror: false)
        
        Thread.currentThread().setContextClassLoader(classLoader)
        
        def testng = new TestNG()
        
        testng.setUseDefaultListeners(false)
        testng.addListener((Object) lightestListener)
        testng.setOutputDirectory(outputDir)
        
        testNGListeners.each { testng.addListener((Object) it) }
        
        setSuites(paths, testng)
        
        lightestListener.setRegistry(finder.find(testng))
        lightestListener.setOutputDir(outputDir)
        lightestListener.initializeReporters(configText)
        
        if (dispatchStrategy instanceof IInterruptibleTaskDispatchStrategy) {
            def isParallel = testng.m_suites.any {
                it.getParallel() == XmlSuite.PARALLEL_TESTS ||
                it.getParallel() == XmlSuite.PARALLEL_METHODS
            }
            
            if (isParallel) {
                dispatchStrategy.setInterruptible(false)
            }
            else if (interactiveStart) {
                interrupt()
            }
        }
        
        testng.run()
    }
    
    /**
     * Starts a thread that reads the runner's input stream for a directive to
     * start interactive mode. This is typically whenever the user hits the
     * Enter/Return key. The content of the line entered will be discarded.
     */
    protected void startInteractiveThread() {
        def interactiveThread = [
            run: {
                def reader = _in.newReader()
            
                while (! interactiveThreadShouldStop) {
                    if (reader.ready()) {
                        reader.readLine()
                        interrupt()
                    }
                    
                    sleep 100
                }
            }
        ] as Thread
        
        interactiveThreadShouldStop = false
        interactiveThread.start()
    }
    
    /**
     * Instructs the input scanning thread to stop at the next convenient time.
     * If the thread is not currently running, this is a no-op.
     */
    protected void stopInteractiveThread() {
        interactiveThreadShouldStop = true
    }
    
    /**
     * Prepares to enter interactive mode in response to user input.
     * Interactive mode cannot be entered when tests are being run in parallel.
     * We can only know whether tests are being run in parallel after the
     * TestNG suites have been set, e.g. after setSuites() has been called.
     * This method will fail with an assertion error if called beforehand. If
     * the current task dispatch strategy does not allow for interruption, this
     * will be a no-op.
     */
    protected void interrupt() {
        if (dispatchStrategy instanceof IInterruptibleTaskDispatchStrategy) {
            if (dispatchStrategy.isInterruptible()) {
                dispatchStrategy.interrupt()
            }
            else {
                out.println ('[interruption not allowed]')
            }
        }
    }
    
    static void main(args) {
        final USAGE = ('java -jar lightest-core-standalone.jar '
            + '[OPTIONS] CONFIGFILE [TESTFILE | SUITEFILE]...')
        
        def cli = new CliBuilder(usage: USAGE, parser: new GnuParser())
        
        cli.i(longOpt: 'interactive', required: false,
            'start in interactive mode')
        cli.b(longOpt: 'batch', required: false,
            'batch mode (disables interactive mode)')
        
        def options = cli.parse(args)
        def argsList = options.getArgs().toList()
        
        if (argsList.size() < 2) {
            cli.usage()
            return
        }
        
        def testRunner = new TestRunner()

        testRunner.configure(new File(argsList.remove(0)))
        
        try {
            if (! options.b) {
                testRunner.startInteractiveThread()
                testRunner.run(argsList, options.i)
                testRunner.stopInteractiveThread()
            }
            else {
                testRunner.run(argsList, false)
            }
        }
        catch (Throwable e) {
            StackTraceUtils.deepSanitize(e)
            e.printStackTrace()
            testRunner.stopInteractiveThread()
            System.exit(1)
        }
    }
}
